每個接到「把編號從三碼改成四碼」需求的人,大概都曾經以為:這個半小時可以做完。
這次收到的需求也只有一句:
「把流水號從三碼改成四碼。」
第一眼看到時,我腦中浮現的修改大概是:
CHAR(3) → CHAR(4)
畫面輸入框的 MaxLength 改成 4,Delphi Dataset Field 的 Size 改成 4,報表欄位不夠寬就再拉開一點。照這份清單往下做,看起來半小時真的可以收工。
但我沒有立刻動手。
因為這個流水號不是只顯示在畫面上的一段字串。每新增一批工料資料,主檔會記住它,明細會靠它找到自己屬於哪一批,歷史資料會拿它判斷先後,查詢與報表也會把它當成篩選或排序條件。
它還會一路流進簽核、匯出與其他模組的回查流程。下一次新增時,系統又必須從既有資料裡找出「目前最後一號」,再產生下一個號碼。
換句話說,如果流水號的意義判斷錯了,受影響的可能不只是一個輸入框。主檔與明細可能對不起來,歷史順序可能排錯,報表可能漏資料,下一號也可能從錯誤的位置繼續往下編。
所以這次我沒有直接把所有 Size = 3 改成 4,而是先去確認:
這三個字元,過去到底是怎麼被產生的?
原本我以為答案應該很單純。既然是流水號,大概就是:
001 → 002 → 003 → ... → 998 → 999
但實際往歷史資料裡看,事情開始變得奇怪。
除了熟悉的數字,我還看到了:
3E8
3E9
3EA
流水號裡怎麼會出現英文字母?
這時問題已經不再是「欄位能不能放四碼」,而是:
這套系統過去到底用什麼規則理解這個流水號?
也就是從這裡開始,原本看似半小時可以完成的小修改,正式變成一場 Legacy System 的考古。
我沿著產號程式與歷史資料往回追,才確認舊系統並不是從第一筆開始就使用十六進位。
一開始的 001~999 仍是一般十進位:
001
002
003
...
998
999
但欄位只有三碼。當年數到 999 後,系統沒有立即擴充欄位,而是改用三碼十六進位延續數值:
999
3E8 ← 十進位 1000
3E9 ← 十進位 1001
3EA ← 十進位 1002
三碼十六進位最多可以表示到:
FFF = 十進位 4095
現在的新規則則是四碼十進位,只使用 0~9。
舊制
001 ───────────── 999 │ 3E8 ───────────── FFF
三碼十進位 │ 三碼十六進位
↓
共同數值
↓
新制
0001 ───────────────────────────── 9999
四碼十進位
因此,資料不能只用「三碼或四碼」判斷,更不能看到三碼就全部套用同一個進位制。
| 儲存字串 | 所屬規則 | 共同數值 | 新制下一號 |
|---|---|---|---|
295 |
999 以前的十進位區段 | 295 | 0296 |
999 |
十進位區段上限 | 999 | 1000 |
3E8 |
十六進位延伸區段 | 1000 | 1001 |
FFF |
三碼十六進位上限 | 4095 | 4096 |
但追到這裡,還有一個不能跳過的識別問題。
十六進位從 3E8 往後增加,接著會經過:
3FE
3FF
400 ← 十六進位 1024
401
問題是,字串 400 早就在前面的十進位階段出現過,當時代表十進位 400。也就是說,同一個三碼字串可能具有兩種語意:
| 儲存字串 | 可能的歷史語意 | 共同數值 |
|---|---|---|
400 |
早期十進位流水號 | 400 |
400 |
後期十六進位延伸值 | 1024 |
如果兩筆資料位於同一個流水號作用域,這甚至可能造成鍵值碰撞;如果舊系統是依工程、案件、建立順序、建立時間或其他欄位區分,就必須把那些 Context 一起納入判斷。
目前我只能確認系統曾使用這兩段規則,還不能只靠流水號字串證明每一筆資料屬於哪一段,也不能替舊系統假設一個尚未找到的防撞機制。
因此,這張圖描述的是歷史規則的演變,不是一個可以單靠字串執行的分類器。後續程式、SQL 與測試若要轉成共同數值,必須先取得案件範圍、建立順序或其他已確認的歷史 Context。
Legacy System 最麻煩的不是格式混在一起,而是同一個字串在不同歷史階段,可能代表不同的東西。
295 → 647確認 001~999 是十進位、超過 999 才改用十六進位後,我原本以為問題已經解釋完了。
只要把兩套規則轉成共同數值,再輸出四碼十進位,不就結束了?
但接著,我在另一筆歷史資料裡看到了這個結果:
正常預期:295 → 296
實際資料:295 → 647
這時事情又變了。
如果只是十進位與十六進位的切換,295 不應該跳到 647。這代表系統裡可能還存在另一個尚未理解的因素。
目前能確認的事實只有兩個:
至於原因,當時都還只是待驗證假設:
例如下面這種查詢看似直覺:
SELECT MAX(ITEM_NO)
FROM ITEM_HEADER;
但資料庫比較的是字串順序,不是流水號的共同數值。混有 0999、1000 與 FFF 時,字串最大值未必是數值最大值,也未必是最後建立的資料。
只找到字串最大的編號,不代表找到數值最大或最後建立的編號。
不過,這仍然只是一個可查證方向,不能看到 MAX 就宣布它是 295 → 647 的真正原因。
這就是 Legacy System 維護最熟悉的節奏:以為已經找到答案,五分鐘後又出現一筆資料告訴你——考古還沒結束。
這時如果我只把部分資訊交給 AI:
舊系統的流水號曾經使用三碼十六進位,
現在要改成四碼十進位。
這句話本身沒有錯,但它少了一個最關鍵的條件:
十六進位不是從第一筆資料就開始使用,而是超過 999 後才加入的延伸規則。
少掉這個 Context,AI 很容易把局部規則概括成全域規則。
例如我再問:
「目前最後一號是
295,改成四碼後,下一號是多少?」
如果把所有三碼舊資料都當成十六進位,就可能得到:
0x295 = 661
下一號 = 662
四碼輸出 = 0662
數學沒有算錯。
但系統答案錯了。
因為按照剛才確認的歷史規則,295 還位於 001~999 的十進位區段。真正應該得到的是:
295
↓
296
↓
0296
這個例子真正提醒我的,不是「AI 連進位制都會算錯」。恰恰相反,AI 可以把進位制算得完全正確,卻因為不知道企業系統的歷史分界,非常精確地套用了一條錯誤規則。
資料可以成功轉型,不代表轉型方式正確。
295 → 0662 是不完整 Context 可能造成的誤判;295 → 647 則是 Production 歷史資料中真正出現、仍需要追查的異常。兩者不能混為一談。
| 情境 | 結果 | 性質 |
|---|---|---|
| 正確歷史規則 | 295 → 0296 |
應有行為 |
| AI 誤套十六進位 | 295 → 0662 |
假設錯誤 |
| Production 歷史資料 | 295 → 647 |
尚未釐清的異常 |
面對 295 → 647,我沒有只問 AI:「為什麼會跳號?」
這種問法很容易得到一串聽起來都合理、卻無法直接採用的答案。我改成先提供已確認的事實,再要求 AI 按照證據來源拆分調查方式:
目前已確認:
1. 001~999 為三碼十進位。
2. 超過 999 後,才使用三碼十六進位延續。
3. 某些歷史資料曾出現目前值 295,
下一筆卻跳到 647 的情況。
4. 十六進位延伸後可能再次出現 400、401 等純數字字串,
不能只靠流水號外觀判斷格式。
5. 目前不知道跳號原因,也尚未確認舊系統如何避免或區分重疊值。
請不要猜測單一答案,也不要直接產生修改程式。
請將可能原因分成:
A. 可以從程式碼驗證
B. 可以從資料庫資料驗證
C. 需要操作重現
D. 無法從目前證據判定
請列出每一類應先檢查的位置、需要的證據,
並區分「已確認事實」、「程式推論」與「待驗證假設」。
這時 AI 的價值不再是替我選一個最像答案的原因,而是協助建立調查順序:
AI 能讀懂眼前的 Code,卻不會自動知道企業系統多年演變留下來的背景。那些沒有寫進註解、只存在資料與維護經驗裡的歷史,必須由工程師補成 Context,它才有機會做出符合現場的分析。
工料流水號也可能同時是顯示值、關聯鍵、查詢條件與排序依據。只搜尋到欄位名稱仍不夠,還要確認每個位置究竟拿它做什麼。
AI 適合協助:
但以下決策仍要由工程師確認:
這不是因為 AI 完全不可靠,而是分工不同:AI 負責協助分析,工程師負責確認商業邊界。
這次我採用的方向是:
💡 工程師判決:欄位長度只是表面,資料語意、歷史分界與相容性才是改版真正的成本。
規則確認完之後,事情才正式進入 Legacy System 最花時間的部分:盤點這個流水號到底穿過哪些地方。
我原本以為搜尋欄位名稱,就能找到所有修改點。實際追下去才發現,同一個值在不同程式裡可能有不同名稱,也可能被放進參數、暫存變數或共用函式,再一路傳到其他 Dataset。
最後整理出的修改路徑大致是:
資料庫欄位
↓
Delphi Dataset Field
↓
畫面輸入元件
↓
Query Parameter/暫存變數
↓
產號程式
↓
主檔/明細傳值
↓
查詢與排序
↓
報表/簽核/匯出
↓
歷史資料相容
這不是把每個地方都從 3 改成 4 就結束。每一層使用流水號的目的不同,需要確認的風險也不一樣。
在 Delphi 裡,把 Dataset Field 的 Size 從 3 改成 4,只能證明這一層願意接受四個字元。
假設主畫面的 Dataset 已經改成 4,但另一個查詢 Dataset 仍保留 3;或是 Query Parameter、暫存變數、共用函式仍假設流水號只有三碼,那麼 0296 可能在流程途中被截斷、轉成整數後失去前導零,甚至在重新查詢時組出不同條件。
畫面上的 MaxLength 也是一樣。它只負責限制使用者能輸入幾個字,並不會自動修正資料庫欄位、參數型別或報表資料來源。
所以盤點時不能只問:
哪些欄位的 Size 是 3?
還要繼續問:
這個值接下來被傳到哪裡?中途有沒有被轉型、補零、截斷或重新組合?
如果流水號同時是主檔與明細的關聯值,兩邊就不能各自理解「同一個號碼」。
下面這種結果在人眼看來似乎都代表二百九十六:
主檔:0296
明細:296
但對資料庫而言,0296 與 296 是兩個不同的字串。若它們參與複合鍵、查詢條件或關聯,明細可能因此查不到主檔,報表也可能出現只有表頭、沒有內容的情況。
因此,新的流水號產生後,應該由同一個來源傳給主檔與所有明細,而不是讓各畫面或各 Dataset 再自行補零、轉型一次。
流水號不只要數值相同,儲存格式也必須完全一致。
欄位擴成四碼後,舊資料並不會憑空消失。查詢結果裡仍可能同時出現 295、3E8、0296 與 1000。
如果某段程式仍直接依字串排序,或拿新制的四碼條件去比較舊制三碼資料,即使新增功能已經能存檔,歷史資料的順序與篩選結果仍可能錯誤。
這也是為什麼前面要先建立「共同數值」。它不只用來產生下一號,也用來讓跨格式的比較有一致語意。
以下是匿名化的簡化範例,不直接對應任何公司資料表。
這段程式有一個必要前提:呼叫端已經依案件範圍、建立順序或其他已確認的歷史條件,將資料判定為 LEGACY_HEX。它不能看到三碼字串就自行宣布這筆資料是十六進位。
DECLARE @LegacyNo varchar(10) = '3E8';
DECLARE @FormatStatus varchar(20) = 'LEGACY_HEX';
DECLARE @HexDigits varchar(16) = '0123456789ABCDEF';
SET @LegacyNo = UPPER(LTRIM(RTRIM(@LegacyNo)));
-- @FormatStatus 必須由已確認的歷史 Context 產生,
-- 不能只依 @LegacyNo 的長度或字元外觀決定。
IF @FormatStatus <> 'LEGACY_HEX'
BEGIN
THROW 50009, N'此筆資料尚未確認為十六進位延伸格式。', 1;
END;
IF LEN(@LegacyNo) <> 3
OR CHARINDEX(SUBSTRING(@LegacyNo, 1, 1), @HexDigits) = 0
OR CHARINDEX(SUBSTRING(@LegacyNo, 2, 1), @HexDigits) = 0
OR CHARINDEX(SUBSTRING(@LegacyNo, 3, 1), @HexDigits) = 0
BEGIN
THROW 50010, N'舊制流水號包含無效的十六進位字元。', 1;
END;
DECLARE @DecimalValue int;
SET @DecimalValue =
(CHARINDEX(SUBSTRING(@LegacyNo, 1, 1), @HexDigits) - 1) * 256
+ (CHARINDEX(SUBSTRING(@LegacyNo, 2, 1), @HexDigits) - 1) * 16
+ (CHARINDEX(SUBSTRING(@LegacyNo, 3, 1), @HexDigits) - 1);
SELECT @DecimalValue AS DecimalValue;
-- 1000
這裡分成兩層防護:先確認歷史分類確實是 LEGACY_HEX,再驗證字串長度與每一個字元。如果把 295 直接丟進換算公式,數學上仍會得到 661,所以真正阻止誤判的不是公式,而是前面的 Context 分類。
如果髒資料找不到對應字元,CHARINDEX 會回傳 0;若沒有字元防呆,後續的 -1 也會悄悄參與計算,產生另一種看似正常的錯誤結果。
取得共同數值後,再依新制輸出下一號:
DECLARE @NextValue int = @DecimalValue + 1;
IF @NextValue > 9999
BEGIN
THROW 50011, N'流水號已超過四碼十進位上限。', 1;
END;
SELECT RIGHT('0000' + CONVERT(varchar(4), @NextValue), 4) AS NextItemNo;
-- 1001
真正重要的不是公式多漂亮,而是順序不能反過來:
辨識歷史規則
→ 轉成共同數值
→ 計算下一號
→ 輸出四碼十進位
不要直接比較兩種格式的字串;先把它們轉成共同數值,再討論大小與下一號。
在正式轉換前,我只會先用唯讀查詢盤點資料,不會一開始就執行 UPDATE。這類使用字串函式與萬用字元的盤點查詢,放到 Production 前仍須確認資料量與 Execution Plan,避免大量掃描影響線上系統。
最乾脆的作法看起來是:把舊資料全部轉成四碼十進位,從此只維護一種格式。
但流水號可能早已存在於主檔、明細、歷史資料、簽核紀錄、報表條件、附件名稱與其他模組。如果其中任何一處沒有一起更新,就可能留下找不到主檔的明細、對不到來源的簽核紀錄,或再也無法由舊條件找到的附件。
更重要的是,現有資料裡還有原因未明的跳號與人工修補痕跡。若在規則尚未完全釐清前全面轉換,原始值會被覆蓋,反而失去追查問題的重要證據。
所以這次採取的策略是:
舊資料:維持原值,依歷史規則解讀
新資料:使用四碼十進位
這不是因為懶得轉換,而是在相容性、可追查性與改版風險之間做出的選擇。等到歷史資料的關聯範圍與異常來源都確認後,再評估是否需要另做資料遷移,會比直接在 Production 執行一場大規模 UPDATE 安全得多。
資料庫成功存入 0296,只能證明資料表容得下這四個字元,還不能證明整個功能已經改完。
真正需要驗證的是完整操作流程:
新增主檔
↓
取得四碼流水號
↓
新增明細
↓
儲存
↓
重新查詢
↓
修改
↓
簽核
↓
報表/匯出
↓
再次讀取歷史舊制資料
我會特別確認三件事:
0296,不會有一邊遺失前導零。真正要驗證的不是資料庫能不能存入
0296,而是0296經過整條系統流程後,仍然被每一層當成同一個流水號。
這種 End-to-End 驗證,也是 AI 很容易漏掉的部分。AI 可以從單一 SQL 或單一事件判斷程式是否合理,但實際系統是否在畫面切換、重新查詢、簽核與報表之間保持一致,仍需要工程師依照真實操作流程逐段確認。
追完資料流、確認新舊規則後,這次真正落地的修改可以分成三層:
Delphi
├─ Dataset Field:3 → 4
├─ 畫面輸入長度:3 → 4
├─ 查詢參數與傳值:確認不截斷、不遺失前導零
└─ Client 不再自行決定下一號
SQL Server
├─ 流水號欄位可容納四碼
├─ 舊制資料先轉成共同數值判斷
├─ 新增資料統一產生四碼十進位
└─ 新資料的格式驗證集中在資料庫
歷史資料
├─ 不批次改寫
├─ 仍依原本的歷史規則解讀
└─ 異常跳號保留原值,供後續追查
到這裡我才敢說,這次需求真的不是把 CHAR(3) 改成 CHAR(4)。
至於產號集中到資料庫之後,如果兩個 Client 同時取得最後一號再加一,仍可能拿到相同號碼。這個併發問題留到 Day 9 再處理。
如果只測 0001 → 0002,很容易得到錯誤的安全感。
至少要涵蓋這些邊界:
| 測試情境 | 目前值 | 預期結果 |
|---|---|---|
| 舊制十進位 | 295 |
0296 |
| 十進位分界 | 999 |
1000 |
| 舊制十六進位 | 3E8 |
1001 |
| 舊制含字母 | 3EA |
依十六進位正確換算 |
| 數字外觀重疊 | 400 |
必須依歷史 Context 判斷為 400 或 1024,不得只看字串 |
| 舊制上限 | FFF |
4096 |
| 新制一般值 | 1001 |
1002 |
| 非法字元 | 2G5 |
阻擋並回報異常 |
| 空值 | NULL/空字串 |
依首號規則處理或阻擋 |
| 原因不明的跳號 | 異常大值 | 依規則警告或阻擋 |
| 修改歷史資料 | 舊制三碼 | 不因新制驗證而失敗 |
其中 FFF 等於十進位 4095,它的下一號 4096 仍可放進四碼欄位。但系統接近 9999 前,也要先決定溢位後怎麼處理。
不要等號碼真的用完,才在 Production 現場開需求會議。那種會議通常很有臨場感,但不太有品質。
目前已實際確認的範圍是:新資料可以取得四碼十進位流水號,主檔與明細保存的是完全相同的字串,存檔後重新查詢也不會因為前導零遺失而找不到資料。
舊資料則維持原本的三碼值,不需要先進行大規模資料遷移;目前抽查與已驗證範圍內的舊資料,仍可依既有流程讀取。 至於修改、簽核、報表與匯出,我仍把它們保留在完整的 End-to-End 回歸清單裡;沒有完成的驗證,不應因為程式可以編譯或單一畫面正常就提前宣布通過。
至於 295 → 647 的歷史跳號,我沒有因為完成四碼改版就宣稱已經找出所有原因。這次先保留異常資料與原始證據,並把新資料的產號責任集中管理;尚未釐清的歷史問題,繼續留在待追查清單裡。
這個結果或許不如「一次把所有問題修乾淨」漂亮,卻更符合 Legacy System 維護的現實:已確認的先修正,未知的保留證據,不用新的改版覆蓋舊問題。
這個案例不只適用於 Delphi 或 ERP。民國年改西元年、固定代碼改 UUID、電話號碼增加區碼,都會遇到類似問題。
可以帶走的核心很簡單:格式改版時,別只檢查新值能不能寫入,還要追蹤它如何穿過整條資料流,並確認歷史資料是否仍能被原本的功能理解。
新規則管理新資料,不應讓歷史資料從此變成不可維護。
400 這類語意重疊值與原因不明的跳號。295、999、3E8、400、FFF、上限值與非法資料。這次真正修改的不是:
CHAR(3) → CHAR(4)
而是:
舊制混合規則
↓
共同數值
↓
新制十進位語意
AI 能快速列出影響範圍、整理轉換程式與產生測試案例,但工程師仍要回答:
這段資料過去代表什麼?從哪裡開始改變?舊資料又要如何繼續活下去?
如果這三題沒有答案,修改欄位只是把不確定性放進更大的容器。
💡 今日金句:欄位只多一碼,真正要修改的卻是整套系統對這段資料的理解。
最後留一題,看看大家會站哪一邊:
如果 Production 已經有一筆來源不明的跳號,你會怎麼做?
A. 直接阻擋新增
B. 警告後允許繼續
C. 先沿用現況,再追查歷史資料
這次我的選擇比較接近 C:先保留現況與證據,再追查原因。換成你,會怎麼選?
下一篇,我們會直擊產號流程裡最容易在測試時正常、上線後才爆開的情境:
兩個人同時按下新增,為什麼會拿到同一個號碼?
下一篇,我們就來處理這個留在產號流程裡的問題。